Roles and custom claims
Supacharger separates three concepts:
roleis Supabase’s database role, normallyauthenticated; do not replace it with an application role.user_roleis the shared, global application role emitted by the custom access-token hook.- resource roles, such as an organisation owner or member, remain authoritative in membership tables and RLS.
Enable the canonical hook
The committed configuration is:
[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/app/custom_access_token_hook"
The migration grants only supabase_auth_admin permission to read app.user_roles and execute the hook. anon, authenticated, and public cannot read the role source or call the hook.
For a hosted project, deploy the migration first, then enable the hook separately in that project's Supabase dashboard:
- Sign in as an organisation or project Owner or Administrator. A Developer or Read-Only account cannot update Auth configuration; the dashboard or Management API may return
403. - Open the intended hosted project and go to Authentication → Hooks.
- Find Custom Access Token, choose Postgres Function (also labelled SQL in some dashboard versions), and select
app.custom_access_token_hook. - Enable and save the hook. Do not select a similarly named function in
publicand do not create another function when the canonical migration is already deployed. - Sign out of the application completely and sign in again, or explicitly refresh the session, so Supabase issues a new access token.
- Call
supabase.auth.getClaims()and confirm thatclaims.user_roleis present. An existing token can remain stale even after the hook is enabled.
A local config.toml controls the local stack; it does not update the hosted dashboard selection automatically. If the migration ledger is current but a fresh hosted token still lacks user_role, re-check the selected project, hook type, schema, function, and the permissions of the dashboard account used to save the setting.
Extend claims without forking Core
Core owns the hook, Supabase-required claims, user_role, and the active-organisation claims. Applications add compact product claims through this developer extension function:
app.custom_access_token_claims_extension(event jsonb, canonical_claims jsonb)
The default function returns {}. To extend it, create a new forward migration and replace only that function body. A developer-owned example is installed at supabase/templates/custom-access-token-claims-extension.sql.
create or replace function app.custom_access_token_claims_extension(
event jsonb,
canonical_claims jsonb
)
returns jsonb
language sql
stable
security invoker
set search_path = ''
as $$
select jsonb_build_object('product_plan', coalesce(plan.lookup_key, 'free'))
from app.product_user_plans plan
where plan.user_id = (event ->> 'user_id')::uuid;
$$;
Return only an object of additional product-owned claims. The Core hook rejects attempts to replace Supabase claims, the global role, or active-organisation context. This keeps the contract DRY: Core maintains the secure merge and reserved names while the application owns its product query.
The function runs as supabase_auth_admin with security invoker. Grant that role only the table access and RLS policy the extension genuinely needs. Preserve the narrow execute grant and revoke browser roles. Keep claims small, avoid personal data, never use user-editable user_metadata for authorisation, and remember that values remain stale until the token refreshes.
Assign a global application role
Roles are administrative data, so change them through a reviewed migration or another separately designed trusted boundary. For example:
update app.user_roles
set role = 'admin'::app.application_role,
updated_at = timezone('utc', now())
where user_id = '00000000-0000-0000-0000-000000000000';
Do not add admin to user_metadata; signed-in users can edit that metadata. Do not expose a generic browser RPC that lets a caller choose their own role.
The new claim appears only in newly issued access tokens. After a role change, sign in again or refresh the session:
const { data, error } = await supabase.auth.refreshSession();
if (error) throw error;
const { data: claimData } = await supabase.auth.getClaims();
console.log(claimData?.claims.user_role);
Use the claim in RLS
Claims can provide a fast coarse-grained check:
create policy admin_can_read_audit_log
on app.audit_log
for select
to authenticated
using ((select auth.jwt() ->> 'user_role') = 'admin');
JWT claims are cached until the access token is refreshed. For access that must be revoked immediately, query authoritative database state in RLS or a server-side domain function instead of relying only on the claim.
Organisation roles
An organisation role is not a single global user role. One person can own one organisation and be a member of another. Core's canonical roles are owner, admin, and member; keep that source of truth in the membership table:
create type app.organisation_role as enum ('owner', 'admin', 'member');
create table app.organisation_members (
organisation_id uuid not null references app.organisations(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role app.organisation_role not null default 'member',
primary key (organisation_id, user_id)
);
Authoritative RLS reads the membership row for the resource being accessed:
create policy organisation_admins_manage_settings
on app.organisation_settings
for all
to authenticated
using (
exists (
select 1
from app.organisation_members member
where member.organisation_id = organisation_settings.organisation_id
and member.user_id = (select auth.uid())
and member.role in ('owner', 'admin')
)
);
The canonical session context stores a selected organisation against the current Auth session_id. The hook emits only that compact context:
{
"active_organisation_id": "3ae1…",
"active_organisation_handle": "acme",
"active_organisation_role": "admin",
"organisation_context_version": 4
}
When the user switches organisation, update the server-owned session context, refresh the JWT, and verify the returned claims. Keep the membership lookup in RLS even when the active context claim is used for navigation or an early rejection. Do not put every organisation membership into the JWT: the claim becomes stale and SSR cookies have practical size limits.
Consumer-specific role names must be mapped at a product boundary or migrated to the canonical enum. They must not silently change the reusable Core claim contract.
TypeScript claim helper
Narrow custom values before using them:
type ApplicationRole = 'user' | 'admin';
export async function getApplicationRole(supabase: SupabaseClient) {
const { data, error } = await supabase.auth.getClaims();
if (error) throw error;
const role = data?.claims.user_role;
const normalisedRole: ApplicationRole = role === 'admin' ? 'admin' : 'user';
return normalisedRole;
}
Use this for display or coarse server routing. Keep final data authorisation in RLS and trusted server code.